bearjaw.dev

Doing swifty things 1-bit at a time 🚀

Testable Networking Code – Part 2

Introduction

There are many paths you can take to make your code testable, especially when it comes to networking.

In this part, I'll walk through how to leverage protocols to improve testability.
Instead of having an APIService class or struct that directly performs requests, we'll create a simple protocol, a mock APIService, and make both conform to the protocol.


Defining a Protocol

protocol APIRequestable {
    func performRequest<T: Decodable>(_ request: APIRequest<T>) async throws -> T
}

// Implementation
final class APIServiceMock: APIRequestable {
    func performRequest<T: Decodable>(_ request: APIRequest<T>) async throws -> T {
        fatalError("TODO: Implement")
    }
}

This looks easy and reusable. Now you can use APIRequestable in your real and test implementations by implementing both the real service and a mock version. Instead of depending directly on APIService, you’ll depend on the protocol.

For simplicity let's use the poor man's dependency injection:

final class UserProfileViewModel {

    private let apiService: APIRequestable
    private var user: User?
    
    init(apiService: APIRequestable) {
        self.apiService = apiService
    }
    
    func fetchCurrentUser() async throws {
        let request = APIEndpoint.User.current()
        user = try await apiService.performRequest(request)
    }
    
    func greeting() -> String {
    if let user {
        return "Hello, \(name)!"
    }
        return "Hello!"
    }
}

...

// In your test
func test_fetches_currentUser() async throws {
    let mockAPIService = APIServiceMock()
    let sut = UserProfileViewModel(apiService: mockAPIService)
    try await sut.fetchCurrentUser()
    let expectedGreeting = "Hello, Tim!"
    let greeting = sut.greeting()
    XCTAssertEqual(greeting, expectedGreeting)
} 

An approach and a problem

While writing a mock can be very little work we're running into a small problem with our networking layer or data pipeline. We're not really in control of the data. Or at least it's very inflexible as we have to return different data per endpoint or even chain endpoints.

Another common approach is to create a protocol for the UserProfileViewModel, implement func loadCurrentUser() async throws and always return a user or create another mock and throw an error. You could even inject the response you want this method to return here as well.

So let's do that quickly (We could also just use Swift's Result type):

enum UserTestAPIResponse {
    case user(User)
    case error(Error)
}

Then we can create a mock class of the ViewModel, implement the protocol to fetch the current user, and return the injected API response.

let testResponse: UserTestAPIResponse
...
func loadCurrentUser() async throws {
    switch testResponse {
        case let user(user): 
            self.user = user
    case let error(error):
            throw error
    }
}
   
// In your test
func test_fetches_currentUser() async throws {
    let expectedUser = User(name: "Tim")
    let response = UserTestAPIResponse.user(expectedUser)
    
    let sut = UserProfileViewModelMock(fetchUserResponse: response)

    try await sut.loadCurrentUser()
    
    let expectedGreeting = "Hello, Tim!"
    let greeting = sut.greeting()
    XCTAssertEqual(greeting, expectedGreeting)
} 

Now the test will pass again and we're happy but the way we got there is not great, we're also not really testing anything, and it doesn't scale.

It looks pretty flexible until you realise that you probably have more than one api call in your viewModel. You'll probably need more responses. Some api calls might be chained. You could create a dictionary and create a unique key for each api call and return it's response. Additionally we had to copy our implementation of greeting(). Not really DRY and what if our real implementation of greeting() ever changes?

So, now you might say subclassing to the rescue! Again, something you could do and you might not have to duplicate your code that way but I do prefer composition if possible over inheritance.

It becomes clear we don't really test our viewModel code but rather test a different part of code using a mock version of our viewModel. Depending on what we want to test we're forced to duplicate our real implementation code.

It's a lot of overhead and and a lot of mocks and classes you need to write. You'll spend more time setting up your mock pipeline than writing tests.

One thing to keep in mind is generally in blog posts or tutorials, the code is always super clean, easy to read, and very small. So while it looks trivial and small it could grow quite fast and we'll also need to maintain our mocks and our responses.

A better way?

Let's go back to the APIService. It's a better option to put the all the heavy lifting code we need to return the correct responses we want to test in our APIServiceMock implementation. However we'll end up in the same situation as with our ViewModel. We're not really testing our real networking / api layer here. We're testing a mock implementation of it.

// In your test
func test_fetches_currentUser() async throws {
    let expectedUser = User(name: "Tim")
    let response = UserTestAPIResponse.user(expectedUser)
    let mockAPIService = UserAPIServiceMock(response: response)
    
    let sut = UserProfileViewModel(apiService: mockAPIService)
    
    try await sut.fetchCurrentUser()
    
    let expectedGreeting = "Hello, Tim!"
    let greeting = sut.greeting()
    XCTAssertEqual(greeting, expectedGreeting)
} 

This approach is a lot better as we no longer have to copy the real implementation of our ViewModel or Model and instead leverage our mock to return the response we want. We also cover the real implementation of our ViewModel with our tests and we no longer need to keep the mock in sync with our actual implementation.

Conclusion

Protocols are a great tool to abstract away concrete types and make your code testable. However, they can also introduce a lot of boilerplate if you’re not careful.

When choosing this approach I found that it's easier to write tests and iterate if I can control the data rather than the UI layer (ViewModel). It might seem irrelvant but if you mock the data pipeline then you only have to write a mock for the service. You could even implement a mock per test.

In a future post I'll leverage my favourite way to take control over the data pipeline.